ποΈGitΠ―ΡΠ°ποΈ
Commit a4522f9f4db01c55dc55cce0e0ced13ac8ff0d1d
Parents : c55d6e0
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-05-26T13:33:23-07:00
Committer : GitHub <noreply@github.com>
Date : 2026-05-26T20:33:23Z
fix(flatpak): generate complete offline-buildable manifest for desktopApp (#5610)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Changes
8 files changed, 277 insertions(+), 112 deletions(-)
Diff
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 86b3c37a97..48a27afb90 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -374,11 +374,15 @@ jobs:
# network β meshtastic.flatpak-ops captures URLs via BuildOperationListener and
# only sees ExternalResourceReadBuildOperation events for cache misses. With the
# shared cache, downloads would be skipped and the manifest would be (nearly) empty.
+ # We drive packageUberJarForCurrentOS β the same task the in-flatpak build invokes β
+ # so the runtime classpath is fully resolved (skiko, ktor-cio, kable-btleplug-ffi, β¦
+ # are runtime-only and would be missed by :assemble).
- name: Generate Flatpak Sources
run: >
./gradlew --no-build-cache --no-configuration-cache
-Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home
- :desktopApp:assemble :captureFlatpakSources
+ -I gradle/init-scripts/flatpak-ops.init.gradle.kts
+ :desktopApp:packageUberJarForCurrentOS :captureFlatpakSources
- name: Stage manifest
run: cp build/flatpak-ops-sources.json flatpak-sources.json
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index cfb7ffaa67..94ea28f9e1 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -576,11 +576,14 @@ jobs:
cache_read_only: true
# Isolated Gradle user home β see explanation in release.yml.
+ # packageUberJarForCurrentOS is what the in-flatpak build invokes; it resolves the FULL
+ # runtime classpath. :assemble alone would miss runtime-only deps (skiko, ktor-cio, β¦).
- name: Generate Flatpak Sources
run: >
./gradlew --no-build-cache --no-configuration-cache
-Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home
- :desktopApp:assemble :captureFlatpakSources
+ -I gradle/init-scripts/flatpak-ops.init.gradle.kts
+ :desktopApp:packageUberJarForCurrentOS :captureFlatpakSources
- name: Stage manifest
run: cp build/flatpak-ops-sources.json flatpak-sources.json
diff --git a/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt b/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt
index e63178969f..9ed009c38a 100644
--- a/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt
+++ b/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt
@@ -17,6 +17,7 @@
package org.meshtastic.flatpakops
import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.internal.project.ProjectInternal
@@ -37,8 +38,9 @@ import java.util.concurrent.ConcurrentHashMap
* Captures every external resource URL Gradle reads via the internal BuildOperationListener API and emits a
* Flathub-compliant flatpak-sources.json at build finish.
*
- * No heuristics: URL is authoritative (taken straight from the build op), the on-disk file is found via Gradle's
- * documented files-2.1 layout, and SHA-256 is computed from that exact file.
+ * URL is authoritative (taken straight from the build op); the on-disk file is found via Gradle's files-2.1 layout,
+ * with a Module-Metadata-aware fallback for jars whose cache name differs from their URL name; SHA-256 is computed from
+ * that exact file.
*
* Internal APIs touched (acceptable trade-off; same path flatpak-gradle-generator uses):
* - org.gradle.internal.operations.BuildOperationListener / BuildOperationListenerManager
@@ -50,12 +52,25 @@ class FlatpakOpsPlugin : Plugin<Project> {
override fun apply(target: Project) {
check(target == target.rootProject) { "meshtastic.flatpak-ops must be applied to the root project" }
- val capturedUrls: MutableSet<String> = ConcurrentHashMap.newKeySet()
- val manager: BuildOperationListenerManager =
- (target as ProjectInternal).services.get(BuildOperationListenerManager::class.java)
-
- val listener = OpListener(capturedUrls)
- manager.addListener(listener)
+ // Prefer the URL set populated by gradle/init-scripts/flatpak-ops.init.gradle.kts.
+ // The init script attaches its listener BEFORE any plugin/project resolution, so it
+ // captures bootstrap downloads (kotlin-dsl plugin marker, build-logic deps) that a
+ // listener registered here would miss. If the init script wasn't passed via -I, we
+ // fall back to a locally-attached listener β incomplete for build-logic deps but
+ // useful for developer debugging. No buildFinished cleanup here: this plugin loads in
+ // every normal build, and gradle.buildFinished is incompatible with the configuration
+ // cache. The fallback is rarely used and the per-build leak is benign.
+ @Suppress("UNCHECKED_CAST")
+ val capturedUrls: MutableSet<String> =
+ (target.gradle.extensions.findByName("flatpakOpsCapturedUrls") as? MutableSet<String>)
+ ?: ConcurrentHashMap.newKeySet<String>().also { fallback ->
+ val manager = (target as ProjectInternal).services.get(BuildOperationListenerManager::class.java)
+ manager.addListener(OpListener(fallback))
+ target.logger.warn(
+ "flatpak-ops: init script not loaded; build-logic bootstrap URLs will be missing. " +
+ "Pass -I gradle/init-scripts/flatpak-ops.init.gradle.kts for a complete manifest.",
+ )
+ }
val outputProvider = target.layout.buildDirectory.file("flatpak-ops-sources.json")
@@ -63,21 +78,14 @@ class FlatpakOpsPlugin : Plugin<Project> {
group = "flatpak"
description = "Emit flatpak-sources.json from URLs captured via BuildOperationListener."
outputs.upToDateWhen { false }
- // Must run AFTER the task that triggers resolution. Without this, Gradle's scheduler
- // may interleave/parallelize this task with :desktopApp:assemble, causing us to write
- // the file before the downloads we want to capture have happened.
- mustRunAfter(":desktopApp:assemble")
+ // Order after the resolution-emitting tasks so we don't snapshot capturedUrls before
+ // their downloads happen. mustRunAfter is conditional β only the scheduled task enforces.
+ mustRunAfter(":desktopApp:assemble", ":desktopApp:packageUberJarForCurrentOS")
val proj = target
val urlsRef = capturedUrls
val outFile = outputProvider
doLast { writeSources(proj, urlsRef.toList(), outFile.get().asFile) }
}
- // Listener is intentionally NOT removed on task completion; it stays attached until JVM
- // exit. Removal is unsafe when our task races against other resolution-emitting tasks.
- // Known limitation: in a long-lived Gradle daemon, capturedUrls accumulates across builds.
- // We can't clear it at task start (that would erase what was captured during assemble).
- // The CI workflow uses a fresh isolated GRADLE_USER_HOME, so this only affects local
- // developer use β and re-emitting a superset of URLs is harmless.
}
private class OpListener(private val urls: MutableSet<String>) : BuildOperationListener {
@@ -118,15 +126,17 @@ class FlatpakOpsPlugin : Plugin<Project> {
val group = rel[0]
val artifact = rel[1]
val version = rel[2]
- val onDiskFilename = rel.last()
val groupPath = group.replace('.', '/')
+ // dest-filename tracks the URL, not the cache: Gradle Module Metadata can declare
+ // files[].name β files[].url, and the offline repo must serve at the URL path.
+ val urlFilename = URI(url).path.trimEnd('/').substringAfterLast('/')
val entry =
mutableMapOf<String, Any>(
"type" to "file",
"url" to url,
"sha256" to sha256(cacheFile),
"dest" to "offline-repository/$groupPath/$artifact/$version",
- "dest-filename" to onDiskFilename,
+ "dest-filename" to urlFilename,
)
mirrorsFor(url).takeIf { it.isNotEmpty() }?.let { entry["mirror-urls"] = it }
entry
@@ -142,12 +152,16 @@ class FlatpakOpsPlugin : Plugin<Project> {
}
/**
- * Last three URL path segments are artifact/version/filename (Maven 2 spec; present in every Maven URL). Gradle's
- * files-2.1 layout is `<group-with-dots>/<artifact>/<version>/<content-sha1>/<filename>`. The (artifact, version,
- * filename) triple is NOT unique across groups (e.g. androidx.annotation:annotation:1.10.0.module collides with
- * org.jetbrains.compose.annotation-internal:annotation:1.10.0.module), so the group MUST be derived from the URL
- * path. We probe every suffix of the path's leading segments against the cache layout β the longest match wins
- * (strips arbitrary repo prefixes like `/maven2/`, `/dl/android/maven2/`, `/m2/` without hardcoding them).
+ * Map a Maven URL to its file in Gradle's files-2.1 cache (layout:
+ * `<group-with-dots>/<artifact>/<version>/<content-sha1>/<filename>`). Group is derived from the URL path β
+ * `(artifact, version, filename)` is not unique across groups (e.g. androidx.annotation:annotation:1.10.0 collides
+ * with org.jetbrains.compose.annotation-internal:annotation:1.10.0), so we probe every suffix of the leading path
+ * segments and the longest match wins (this also strips arbitrary repo prefixes like `/maven2/`, `/m2/`).
+ *
+ * Two-tier lookup: the fast path assumes the cache filename matches the URL filename (true for pom/module always,
+ * and most jars). For jars where Gradle Module Metadata renames the artifact locally (files[].name β files[].url β
+ * e.g. com.mikepenz:aboutlibraries-compose-core-jvm publishes aboutlibraries-compose-core-jvm-14.2.1.jar but caches
+ * it as aboutlibraries-compose-jvm.jar) we parse the sibling .module file to resolve the real cache path.
*/
private fun locateCacheFile(filesRoot: File, url: String): File? {
val path = URI(url).path.trimEnd('/').split('/').filter { it.isNotEmpty() }
@@ -155,18 +169,36 @@ class FlatpakOpsPlugin : Plugin<Project> {
if (!filesRoot.isDirectory || tail.size < URL_TRAILING_SEGMENTS) return null
val (artifact, version, filename) = tail
val groupCandidates = path.dropLast(URL_TRAILING_SEGMENTS)
- // files-2.1 uses dot-joined group as a SINGLE directory, e.g. `androidx.annotation/`,
- // not `androidx/annotation/`. So join URL segments with '.' here.
+ // files-2.1 uses dot-joined group as a SINGLE directory, e.g. `androidx.annotation/`, not
+ // `androidx/annotation/` β so join with '.' here.
return groupCandidates.indices.firstNotNullOfOrNull { start ->
val groupDir = groupCandidates.drop(start).joinToString(".")
- File(filesRoot, "$groupDir/$artifact/$version")
- .takeIf(File::isDirectory)
- ?.listFiles { f -> f.isDirectory }
- ?.map { shaDir -> File(shaDir, filename) }
- ?.firstOrNull { it.isFile }
+ val versionDir =
+ File(filesRoot, "$groupDir/$artifact/$version").takeIf(File::isDirectory)
+ ?: return@firstNotNullOfOrNull null
+ val shaDirs = versionDir.listFiles { f -> f.isDirectory } ?: return@firstNotNullOfOrNull null
+ shaDirs.map { shaDir -> File(shaDir, filename) }.firstOrNull { it.isFile }
+ ?: filename.takeIf { it.endsWith(".jar") }?.let { resolveJarViaModuleMetadata(versionDir, shaDirs, it) }
}
}
+ @Suppress("UNCHECKED_CAST")
+ private fun resolveJarViaModuleMetadata(versionDir: File, shaDirs: Array<File>, urlFilename: String): File? {
+ val moduleFile =
+ shaDirs.firstNotNullOfOrNull { dir ->
+ dir.listFiles()?.firstOrNull { it.isFile && it.name.endsWith(".module") }
+ }
+ val entry =
+ moduleFile
+ ?.let { runCatching { JsonSlurper().parse(it) as? Map<String, Any?> }.getOrNull() }
+ ?.let { it["variants"] as? List<Map<String, Any?>> }
+ ?.flatMap { (it["files"] as? List<Map<String, Any?>>).orEmpty() }
+ ?.firstOrNull { it["url"] == urlFilename }
+ val name = entry?.get("name") as? String
+ val sha1 = entry?.get("sha1") as? String
+ return if (name != null && sha1 != null) File(versionDir, "$sha1/$name").takeIf(File::isFile) else null
+ }
+
/**
* Derive fallback mirror URLs from the primary URL's host. Only Maven Central has well-known public mirrors; for
* everything else (Google, JitPack, Gradle plugin portal, snapshot repos), we trust the primary URL since these
diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerWithDbRetryTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerWithDbRetryTest.kt
index d01540766f..7b16c330d5 100644
--- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerWithDbRetryTest.kt
+++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerWithDbRetryTest.kt
@@ -77,11 +77,17 @@ class DatabaseManagerWithDbRetryTest {
val result = async {
manager.withDb { db ->
visitedDbs += db
- when (++attempts) {
- 1 -> {
- started.complete(Unit)
- continueFirstAttempt.await()
- }
+ if (++attempts == 1) {
+ started.complete(Unit)
+ continueFirstAttempt.await()
+ // Simulate the race the retry path is supposed to handle: oldDb's pool
+ // was closed between when we captured it and when we read from it. The
+ // previous version of this test triggered this by calling oldDb.close()
+ // and racing against the resumed read β which flapped in CI because
+ // Room's close() is not strictly ordered against in-flight reads.
+ // Throwing the representative exception directly makes the retry path
+ // deterministic; isDbClosedException matches "closed" + ("pool"|β¦).
+ error("Connection pool is closed")
}
db.nodeInfoDao().getMyNodeInfo().first()?.myNodeNum
}
@@ -93,7 +99,6 @@ class DatabaseManagerWithDbRetryTest {
val newDb = manager.currentDb.value
newDb.nodeInfoDao().setMyNodeInfo(newMyNodeInfo)
- oldDb.close()
continueFirstAttempt.complete(Unit)
assertEquals(newMyNodeInfo.myNodeNum, result.await())
diff --git a/gradle/init-scripts/flatpak-ops.init.gradle.kts b/gradle/init-scripts/flatpak-ops.init.gradle.kts
new file mode 100644
index 0000000000..738713c0f9
--- /dev/null
+++ b/gradle/init-scripts/flatpak-ops.init.gradle.kts
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * Init script for meshtastic.flatpak-ops. Attaches a BuildOperationListener
+ * BEFORE any project or plugin resolution happens β which is necessary because
+ * the flatpak-ops plugin itself lives in build-logic, and any artifacts pulled
+ * to bootstrap build-logic (kotlin-dsl plugin marker, detekt, etc.) would be
+ * invisible to a listener registered later from a root-project plugin.
+ *
+ * Captured URLs are stored on `gradle.extensions` under the key below; the
+ * captureFlatpakSources task (registered by FlatpakOpsPlugin) reads them.
+ *
+ * Pass to Gradle via:
+ * ./gradlew -I gradle/init-scripts/flatpak-ops.init.gradle.kts ...
+ */
+
+import org.gradle.api.internal.GradleInternal
+import org.gradle.internal.operations.BuildOperationDescriptor
+import org.gradle.internal.operations.BuildOperationListener
+import org.gradle.internal.operations.BuildOperationListenerManager
+import org.gradle.internal.operations.OperationFinishEvent
+import org.gradle.internal.operations.OperationIdentifier
+import org.gradle.internal.operations.OperationProgressEvent
+import org.gradle.internal.operations.OperationStartEvent
+import org.gradle.internal.resource.ExternalResourceReadBuildOperationType
+import java.util.concurrent.ConcurrentHashMap
+
+val capturedUrls: MutableSet<String> = ConcurrentHashMap.newKeySet()
+gradle.extensions.add("flatpakOpsCapturedUrls", capturedUrls)
+
+val manager =
+ (gradle as GradleInternal).services.get(BuildOperationListenerManager::class.java)
+
+val listener =
+ object : BuildOperationListener {
+ override fun started(op: BuildOperationDescriptor, e: OperationStartEvent) = Unit
+ override fun progress(id: OperationIdentifier, e: OperationProgressEvent) = Unit
+ override fun finished(op: BuildOperationDescriptor, e: OperationFinishEvent) {
+ val details = op.details as? ExternalResourceReadBuildOperationType.Details ?: return
+ if (e.failure != null) return
+ capturedUrls.add(details.location)
+ }
+ }
+manager.addListener(listener)
+
+// Detach at build finish so a long-lived daemon doesn't accumulate one listener per build.
+// buildFinished is deprecated and breaks the configuration cache β fine here because this
+// script is only loaded via `-I` in the verify flow, which uses --no-configuration-cache.
+// Revisit (Flow API or AutoCloseable BuildService) when we drop Gradle 9 support.
+@Suppress("DEPRECATION")
+gradle.buildFinished { manager.removeListener(listener) }
diff --git a/scripts/verify-flatpak/README.md b/scripts/verify-flatpak/README.md
index 46b593e5a9..d2efdb99fd 100644
--- a/scripts/verify-flatpak/README.md
+++ b/scripts/verify-flatpak/README.md
@@ -5,7 +5,7 @@ can validate `flatpak-sources.json` end-to-end without round-tripping through hi
## What it tests that our CI doesn't
-Our CI (`:desktopApp:assemble :captureFlatpakSources`) only proves the manifest can be *generated*.
+Our CI (`:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources`) only proves the manifest can be *generated*.
Vid's CI is where the manifest actually gets *consumed* by `flatpak-builder`. This script
runs that step locally:
@@ -28,15 +28,28 @@ instead of waiting on cross-repo CI.
- Docker (Docker Desktop on macOS works β the container needs `--privileged` to use
bubblewrap; that's enabled by default).
- ~10 GB free disk for the SDK + Gradle cache.
-- A populated Gradle cache (`./gradlew :desktopApp:assemble` must have run; the script
- does this implicitly via `:captureFlatpakSources`).
+- A populated Gradle cache (`./gradlew :desktopApp:packageUberJarForCurrentOS` must have
+ run; the script does this implicitly via `:captureFlatpakSources`).
## Usage
```bash
-# Full offline build (~10β20 min the first time, faster after β Docker image is cached)
+# Full offline build β Linux host required (~15β30 min first time)
scripts/verify-flatpak/verify.sh
+# URLs + sha256 verification only; skips the Gradle build phase.
+# Works on macOS where nested bwrap fails under Docker Desktop's seccomp.
+scripts/verify-flatpak/verify.sh --download-only
+
+# Reuse an already-generated flatpak-sources.json (don't re-run Gradle)
+scripts/verify-flatpak/verify.sh --skip-regen
+
+# Tight iteration loop after a failed run: refresh overlay yaml + manifest
+# only, then re-run flatpak-builder. Skips Gradle regen, vid-repo fetch,
+# and Meshtastic-Android rsync. Use when you've just patched the YAML
+# overlay or regenerated flatpak-sources.json by hand.
+scripts/verify-flatpak/verify.sh --rebuild-only
+
# Cross-arch test via QEMU emulation (slower)
scripts/verify-flatpak/verify.sh --arch aarch64
@@ -44,12 +57,20 @@ scripts/verify-flatpak/verify.sh --arch aarch64
scripts/verify-flatpak/verify.sh --shell
```
+### macOS limitation
+
+`flatpak-builder` runs the build phase inside `bwrap` (bubblewrap). Nested
+bwrap fails inside Docker Desktop on macOS with
+`prctl(PR_SET_SECCOMP) EINVAL`. The script refuses to run a full build on
+macOS by default β pass `--download-only` to validate URLs + sha256s without
+executing the Gradle build, or run the full script on a Linux host.
+
## Interpreting failures
| Symptom | Likely cause |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `Error downloading mirror: ... 404` on `repo.maven.apache.org` | URL captured by the listener was wrong or artifact moved. Check the source repo hosting it. |
-| `sha256 mismatch` | Stale `flatpak-sources.json`; re-run `:desktopApp:assemble :captureFlatpakSources`. |
+| `sha256 mismatch` | Stale `flatpak-sources.json`; re-run `:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources`. |
| Gradle: `Could not resolve all artifacts ... offline mode` | Missing dep in the manifest β usually a compiler plugin or BOM that wasn't downloaded during capture. |
## Files
diff --git a/scripts/verify-flatpak/desktop-offline.yaml b/scripts/verify-flatpak/desktop-offline.yaml
index 5511f201d8..f4e1e32333 100644
--- a/scripts/verify-flatpak/desktop-offline.yaml
+++ b/scripts/verify-flatpak/desktop-offline.yaml
@@ -68,17 +68,19 @@ modules:
- install -Dm644 -t /app/share/icons/hicolor/scalable/apps org.meshtastic.desktop.svg
- install -Dm644 -t /app/share/applications org.meshtastic.desktop.desktop
- install -Dm644 -t /app/share/metainfo org.meshtastic.desktop.metainfo.xml
+ # Redirect the Gradle wrapper to the bundled distribution (no network).
+ - sed -i 's|distributionUrl=.*|distributionUrl=gradle-all.zip|' gradle/wrapper/gradle-wrapper.properties
- echo "org.gradle.java.installations.auto-detect=false" >> gradle.properties
- echo "org.gradle.java.installations.auto-download=false" >> gradle.properties
- echo "org.gradle.java.installations.paths=/usr/lib/sdk/openjdk21/jvm/openjdk-21,/usr/lib/sdk/openjdk17/jvm/openjdk-17" >> gradle.properties
- >
sed -i
's/^\(\s*\)vendor\.set(JvmVendorSpec\.JETBRAINS)/\1\/\/ vendor.set(JvmVendorSpec.JETBRAINS)/'
- desktop/build.gradle.kts
+ desktopApp/build.gradle.kts
# Force Gradle to resolve from the bundled offline-repository ONLY (true offline test).
- - ./gradlew --offline :desktop:packageUberJarForCurrentOS
+ - ./gradlew --offline :desktopApp:packageUberJarForCurrentOS
- >
- JAR_FILE=$(find desktop/build/compose/jars/ -name "*.jar" -type f | head -1)
+ JAR_FILE=$(find desktopApp/build/compose/jars/ -name "*.jar" -type f | head -1)
&& install -Dm755 "$JAR_FILE" /app/lib/meshtastic-desktop.jar
sources:
- type: file
@@ -90,8 +92,10 @@ modules:
- type: dir
path: meshtastic-android
- type: file
- url: https://services.gradle.org/distributions/gradle-9.4.1-bin.zip
- sha256: 2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
+ # Must match the version in gradle/wrapper/gradle-wrapper.properties.
+ # Bumping the wrapper? Update both the URL and sha256 here.
+ url: https://services.gradle.org/distributions/gradle-9.5.1-all.zip
+ sha256: c72fb9991f6025cbe337d52ba77e531b3faf62bdd3e348fe1ccee9f51c71adb0
dest: "gradle/wrapper"
- dest-filename: "gradle-bin.zip"
+ dest-filename: "gradle-all.zip"
- flatpak-sources.json
diff --git a/scripts/verify-flatpak/verify.sh b/scripts/verify-flatpak/verify.sh
index 30e8888247..c8a6f49346 100755
--- a/scripts/verify-flatpak/verify.sh
+++ b/scripts/verify-flatpak/verify.sh
@@ -1,33 +1,50 @@
#!/usr/bin/env bash
-# Local replica of vid's flatpak CI (vidplace7/org.meshtastic.desktop, .github/workflows/build-flatpak.yml)
-# but flipped to true-offline mode: our flatpak-sources.json is included and --share=network is removed.
+# Local replica of vid's flatpak CI (vidplace7/org.meshtastic.desktop,
+# .github/workflows/build-flatpak.yml) but flipped to true-offline mode: our
+# flatpak-sources.json is included and --share=network is removed from the
+# build phase.
#
-# Goal: validate flatpak-sources.json without bugging vid to push & re-run his workflow.
+# Goal: validate flatpak-sources.json end-to-end (download + verify sha256s +
+# offline Gradle build) without bugging vid to push & re-run his workflow.
#
# Requirements:
-# - Docker (Docker Desktop on macOS is fine; needs ~10GB free + ability to run --privileged)
-# - This Meshtastic-Android checkout has produced flatpak-sources.json
-# (run `./gradlew :desktopApp:assemble :captureFlatpakSources` first, or this script will do it)
+# - Docker (Docker Desktop on macOS works for --download-only mode; full builds
+# need a Linux host because flatpak-builder uses nested bwrap which fails
+# under Docker Desktop's seccomp sandbox).
+# - ~15GB free disk for the SDK + Gradle cache + builddir.
#
# Usage:
-# scripts/verify-flatpak/verify.sh # full build, x86_64
-# scripts/verify-flatpak/verify.sh --arch aarch64 # cross-arch via QEMU emulation
-# scripts/verify-flatpak/verify.sh --shell # drop into the container shell instead of building
+# scripts/verify-flatpak/verify.sh # full offline build (Linux only)
+# scripts/verify-flatpak/verify.sh --download-only # URLs+sha256 only (works on macOS)
+# scripts/verify-flatpak/verify.sh --arch aarch64 # cross-arch via QEMU emulation
+# scripts/verify-flatpak/verify.sh --shell # drop into builder container shell
+# scripts/verify-flatpak/verify.sh --skip-regen # reuse flatpak-sources.json; still re-clone vid + re-rsync source
+# scripts/verify-flatpak/verify.sh --rebuild-only # tight loop: refresh overlay+manifest only, then re-run flatpak-builder
+#
+# Iteration tip: after a build fails partway, fix the overlay YAML (or the
+# Meshtastic-Android source) and re-run with --rebuild-only β Gradle regen,
+# vid-repo fetch, and full source rsync are all skipped, so you get straight
+# back to flatpak-builder in seconds.
set -euo pipefail
ARCH="x86_64"
DROP_TO_SHELL=0
+DOWNLOAD_ONLY=0
+SKIP_REGEN=0
+REBUILD_ONLY=0
while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--shell) DROP_TO_SHELL=1; shift ;;
- -h|--help) sed -n '2,17p' "$0"; exit 0 ;;
+ --download-only) DOWNLOAD_ONLY=1; shift ;;
+ --skip-regen) SKIP_REGEN=1; shift ;;
+ --rebuild-only) REBUILD_ONLY=1; SKIP_REGEN=1; shift ;;
+ -h|--help) sed -n '2,28p' "$0"; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
-# Map flatpak arch names to docker platform names
case "$ARCH" in
x86_64) DOCKER_PLATFORM="linux/amd64" ;;
aarch64) DOCKER_PLATFORM="linux/arm64" ;;
@@ -38,11 +55,12 @@ REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
WORK="$REPO_ROOT/build/flatpak-verify"
OVERLAY="$REPO_ROOT/scripts/verify-flatpak/desktop-offline.yaml"
SOURCES_JSON="$REPO_ROOT/flatpak-sources.json"
+GRADLE_HOME_ISOLATED="$REPO_ROOT/build/flatpak-gradle-home"
VID_REPO="https://github.com/vidplace7/org.meshtastic.desktop.git"
-# Image provides flatpak + flatpak-builder. The freedesktop 25.08 runtime declared in
-# the manifest is pulled from flathub at build time (no 25.08 image exists yet; 24.08 is
-# fine as the builder host because the SDK used at compile time comes from flathub).
+# bilelmoussaoui's image is what vid's CI uses; freedesktop-24.08 is the latest
+# tag available. The 25.08 runtime declared in the manifest is pulled from
+# flathub at build time inside the container.
BUILDER_IMAGE="bilelmoussaoui/flatpak-github-actions:freedesktop-24.08"
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
@@ -50,67 +68,94 @@ fail() { printf '\033[1;31m!! %s\033[0m\n' "$*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 || fail "docker is required; install Docker Desktop or equivalent."
-step "Ensuring flatpak-sources.json is fresh"
-if [[ ! -f "$SOURCES_JSON" ]]; then
- (cd "$REPO_ROOT" && ./gradlew --no-build-cache --no-configuration-cache :desktopApp:assemble :captureFlatpakSources)
+# Refuse full-build mode on macOS β nested bwrap fails under Docker Desktop's
+# seccomp and the user will spend 20 minutes finding out. They can override
+# with --download-only.
+if [[ "$(uname -s)" == "Darwin" && $DOWNLOAD_ONLY -eq 0 && $DROP_TO_SHELL -eq 0 ]]; then
+ fail "Full flatpak-builder runs require a Linux host (nested bwrap fails under Docker Desktop on macOS). Re-run with --download-only, or use --shell to poke around manually."
+fi
+
+if [[ $SKIP_REGEN -eq 0 ]]; then
+ step "Regenerating flatpak-sources.json via isolated Gradle home"
+ rm -rf "$GRADLE_HOME_ISOLATED"
+ # Drive the same task the in-flatpak build runs so runtime-classpath deps (skiko, ktor-cio,
+ # datastore-proto, etc.) are resolved and captured β :assemble only triggers compileClasspath.
+ (cd "$REPO_ROOT" && ./gradlew --no-build-cache --no-configuration-cache \
+ -Dgradle.user.home="$GRADLE_HOME_ISOLATED" \
+ -I gradle/init-scripts/flatpak-ops.init.gradle.kts \
+ :desktopApp:packageUberJarForCurrentOS :captureFlatpakSources)
cp "$REPO_ROOT/build/flatpak-ops-sources.json" "$SOURCES_JSON"
+elif [[ ! -f "$SOURCES_JSON" ]]; then
+ fail "--skip-regen specified but $SOURCES_JSON does not exist."
fi
-step "Preparing workspace at $WORK"
-mkdir -p "$WORK"
-if [[ ! -d "$WORK/org.meshtastic.desktop/.git" ]]; then
- git clone --depth 1 --recurse-submodules "$VID_REPO" "$WORK/org.meshtastic.desktop"
+if [[ $REBUILD_ONLY -eq 1 ]]; then
+ [[ -d "$WORK/org.meshtastic.desktop/.git" ]] || \
+ fail "--rebuild-only needs an existing workspace at $WORK; run without it once first."
else
- git -C "$WORK/org.meshtastic.desktop" fetch --depth 1 origin main
- git -C "$WORK/org.meshtastic.desktop" reset --hard origin/main
- git -C "$WORK/org.meshtastic.desktop" submodule update --init --recursive --depth 1
+ step "Preparing workspace at $WORK"
+ mkdir -p "$WORK"
+ if [[ ! -d "$WORK/org.meshtastic.desktop/.git" ]]; then
+ git clone --depth 1 --recurse-submodules "$VID_REPO" "$WORK/org.meshtastic.desktop"
+ else
+ git -C "$WORK/org.meshtastic.desktop" fetch --depth 1 origin main
+ git -C "$WORK/org.meshtastic.desktop" reset --hard origin/main
+ git -C "$WORK/org.meshtastic.desktop" submodule update --init --recursive --depth 1
+ fi
fi
+# Always refreshed β these are the iteration knobs:
+# overlay yaml = the manifest we're testing
+# flatpak-sources.json = the artifact we're validating
step "Wiring overlay manifest + our flatpak-sources.json"
cp "$OVERLAY" "$WORK/org.meshtastic.desktop/org.meshtastic.desktop.yaml"
cp "$SOURCES_JSON" "$WORK/org.meshtastic.desktop/flatpak-sources.json"
-# Materialize a clean copy of our checkout (excluding build outputs) for `type: dir`.
-# flatpak-builder copies the whole tree β skip heavy/irrelevant paths.
-step "Snapshotting Meshtastic-Android checkout (excluding build/, .gradle/)"
-rsync -a --delete \
- --exclude='/build/' \
- --exclude='/.gradle/' \
- --exclude='*/build/' \
- --exclude='*/.gradle/' \
- --exclude='/.idea/' \
- --exclude='/local.properties' \
- "$REPO_ROOT/" "$WORK/org.meshtastic.desktop/meshtastic-android/"
+if [[ $REBUILD_ONLY -eq 0 ]]; then
+ step "Snapshotting Meshtastic-Android checkout (excluding build/, .gradle/)"
+ rsync -a --delete \
+ --exclude='/build/' \
+ --exclude='/.gradle/' \
+ --exclude='*/build/' \
+ --exclude='*/.gradle/' \
+ --exclude='/.idea/' \
+ --exclude='/local.properties' \
+ "$REPO_ROOT/" "$WORK/org.meshtastic.desktop/meshtastic-android/"
+fi
step "Pulling builder image: $BUILDER_IMAGE ($DOCKER_PLATFORM)"
docker pull --platform "$DOCKER_PLATFORM" "$BUILDER_IMAGE" >/dev/null
+DOCKER_RUN_ARGS=(
+ --rm
+ --privileged
+ -v "$WORK/org.meshtastic.desktop:/work"
+ -w /work
+ --platform "$DOCKER_PLATFORM"
+ --security-opt seccomp=unconfined
+)
+
if [[ $DROP_TO_SHELL -eq 1 ]]; then
step "Dropping into builder shell β flatpak-builder is on PATH"
- exec docker run --rm -it --privileged \
- -v "$WORK/org.meshtastic.desktop:/work" \
- -w /work \
- --platform "$DOCKER_PLATFORM" \
- --security-opt seccomp=unconfined \
- "$BUILDER_IMAGE" bash
+ exec docker run -it "${DOCKER_RUN_ARGS[@]}" "$BUILDER_IMAGE" bash
+fi
+
+# Build flatpak-builder invocation. --download-only mode skips the bwrap-based
+# build phase, which is the part that fails under Docker Desktop on macOS.
+if [[ $DOWNLOAD_ONLY -eq 1 ]]; then
+ BUILDER_EXTRA_FLAGS="--download-only"
+ SUCCESS_MSG="All sources downloaded and sha256-verified successfully (URLs + hashes OK; Gradle build NOT exercised)"
+else
+ BUILDER_EXTRA_FLAGS=""
+ SUCCESS_MSG="Full offline build succeeded β flatpak-sources.json is complete and self-sufficient"
fi
-step "Running flatpak-builder (arch=$ARCH)"
-docker run --rm --privileged \
- -v "$WORK/org.meshtastic.desktop:/work" \
- -w /work \
- --platform "$DOCKER_PLATFORM" \
- --security-opt seccomp=unconfined \
- "$BUILDER_IMAGE" \
- bash -c "set -e
- flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
- # --download-only verifies every source URL + sha256 and exits before the bwrap
- # sandbox phase. We do this because nested bwrap fails inside Docker Desktop on
- # macOS (prctl(PR_SET_SECCOMP) EINVAL). For full sandbox build, run on Linux directly
- # β or rely on vid's GHA CI which uses bare ubuntu-24.04 runners.
- flatpak-builder --user --repo=repo --install-deps-from=flathub --force-clean \
- --disable-rofiles-fuse --download-only \
- builddir org.meshtastic.desktop.yaml
- echo
- echo '=== All sources downloaded and sha256-verified successfully ==='
- "
+step "Running flatpak-builder (arch=$ARCH, mode=$([[ $DOWNLOAD_ONLY -eq 1 ]] && echo download-only || echo full-build))"
+docker run "${DOCKER_RUN_ARGS[@]}" "$BUILDER_IMAGE" bash -c "set -e
+ flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
+ flatpak-builder --user --repo=repo --install-deps-from=flathub --force-clean \
+ --disable-rofiles-fuse $BUILDER_EXTRA_FLAGS \
+ builddir org.meshtastic.desktop.yaml
+ echo
+ echo '=== $SUCCESS_MSG ==='
+"
Served by rngit 1.5.0 - Generated in 0.15s